Цей набір даних містить інформацію про кожен населений пункт України: від великого міста до найменшого села. У ньому представлена наступна інформація: назва населеного пункту, приналежність до району і області, дата проведення нормативної оцінки, чисельність населення, площа населеного пункту, кошторис на вартість технічної документації, середня ціна землі за квадратний метр, цікаві регіональні коефіцієнти, які описують наприклад приналежність міста до курортної зони, коефіцієнт містобудівної цінності.
У даному проекті були використані дані, які стосуються лише населених пунктів Івано-Франківської області. Отримані візуалізації допомагають дати відповіді на деякі запитання, можуть принести локальну користь для держслужбовців чи інвесторів.
from utils import *
df = pd.read_excel("data/ngo_2021_07_01.xlsx", sheet_name='ІВАНО-ФРАНКІВСЬКА', skiprows=[0, 1, 2, 3])
df.columns = ['code', 'status', 'name', 'rada_name', 'raion_name', 'oblast_name', 'year', 'date', 'number', 'square', 'population', 'documentation_price', 'price', 'population_coeff', 'near_110k_city_coeff', 'resort_coeff', 'radiation_coeff', 'city_building_min_coeff', 'city_building_max_coeff']
df = df[df['raion_name'].notna()]
df.head()
| code | status | name | rada_name | raion_name | oblast_name | year | date | number | square | population | documentation_price | price | population_coeff | near_110k_city_coeff | resort_coeff | radiation_coeff | city_building_min_coeff | city_building_max_coeff | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2620480401 | СЕЛО | БАБЧЕ | БАБЧЕНСЬКА СІЛЬСЬКА РАДА | БОГОРОДЧАНСЬКИЙ РАЙОН | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 10!05!2011 | № 8-5/11 | 895,3 | 2501 | 8.200 | 17.32 | 1.0 | 1.0 | 1.0 | 1.0 | 0.80 | 1.18 |
| 1 | 2620455100 | СЕЛИЩЕ МІСЬКОГО ТИПУ | БОГОРОДЧАНИ | БОГОРОДЧАНСЬКА СЕЛИЩНА РАДА | БОГОРОДЧАНСЬКИЙ РАЙОН | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 18!10!2011 | № 401 | 687,6 | 7686 | 30.000 | 45.63 | 1.0 | 1.1 | 1.0 | 1.0 | 0.79 | 1.28 |
| 2 | 2620480601 | СЕЛО | БОГРІВКА | БОГРІВСЬКА СІЛЬСЬКА РАДА | БОГОРОДЧАНСЬКИЙ РАЙОН | ІВАНО-ФРАНКІВСЬКА | 2012.0 | 29!12!2012 | №1-22/2012 | 396,3 | 869 | 5.352 | 22.92 | 1.0 | NaN | NaN | NaN | 0.86 | 1.22 |
| 3 | 2620480901 | СЕЛО | ГЛИБІВКА | ГЛИБІВСЬКА СІЛЬСЬКА РАДА | БОГОРОДЧАНСЬКИЙ РАЙОН | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 15!09!2011 | № 77-8/2011 | 427,27 | 1047 | 4.600 | 24.20 | 1.0 | 1.1 | 1.0 | 1.0 | 0.75 | 1.14 |
| 4 | 2620480801 | СЕЛО | ГЛИБОКА | ГЛИБОКІВСЬКА СІЛЬСЬКА РАДА | БОГОРОДЧАНСЬКИЙ РАЙОН | ІВАНО-ФРАНКІВСЬКА | 2013.0 | 10!07!2013 | №383-19/2013 | 480,3 | 1452 | 7.636 | 21.84 | NaN | NaN | NaN | NaN | 0.81 | 1.15 |
print(df['raion_name'].unique())
['БОГОРОДЧАНСЬКИЙ РАЙОН' 'ВЕРХОВИНСЬКИЙ РАЙОН' 'ГАЛИЦЬКИЙ РАЙОН' 'ГОРОДЕНКІВСЬКИЙ РАЙОН' 'ДОЛИНСЬКИЙ РАЙОН' 'КАЛУСЬКИЙ РАЙОН' 'КОЛОМИЙСЬКИЙ РАЙОН' 'КОСІВСЬКИЙ РАЙОН' 'НАДВІРНЯНСЬКИЙ РАЙОН' 'РОГАТИНСЬКИЙ РАЙОН' 'РОЖНЯТІВСЬКИЙ РАЙОН' 'СНЯТИНСЬКИЙ РАЙОН' 'ТИСМЕНИЦЬКИЙ РАЙОН' 'ТЛУМАЦЬКИЙ РАЙОН' 'БОЛЕХІВ' 'ІВАНО-ФРАНКІВСЬК' 'КАЛУШ' 'КОЛОМИЯ' 'ЯРЕМЧЕ']
Для парсингу географічних даних, а саме полігонів районів і міст, я використав сервіс OpenStreetMap, який надає зручні візуальні утиліти, а також API для доступу до географічних даних. Спочатку я формую запит для отриманні спеціального ID кожного населеного пункту, а пізніше з його допомогою викачую необхідні полігони і зберігаю в потрібному форматі.
geometries = {}
for name in df['raion_name'].unique():
try:
df_local = get_region_geodata(name.lower())
geometries[name.lower()] = df_local['geometry'][0][0] #.boundary
except:
print("\n\n\n =========", name, "\n\n")
continue
using old коломийський
geometries2 = {"name": list(geometries.keys()), "geometry": list(geometries.values())}
map_if = pd.DataFrame.from_dict(geometries2)
map_if.head()
| name | geometry | |
|---|---|---|
| 0 | богородчанський район | (POLYGON ((24.1241848 48.534142, 24.1246811 48... |
| 1 | верховинський район | (POLYGON ((24.5543966 48.1179534, 24.5555185 4... |
| 2 | галицький район | (POLYGON ((24.5025238 49.2231541, 24.5017353 4... |
| 3 | городенківський район | (POLYGON ((25.2097308 48.7107219, 25.2095177 4... |
| 4 | долинський район | (POLYGON ((23.5470764 48.7253908, 23.5477937 4... |
gdf = gpd.GeoDataFrame(map_if, geometry=map_if['geometry'])
gdf = gdf.head(len(gdf)-1)
gdf.head()
| name | geometry | |
|---|---|---|
| 0 | богородчанський район | MULTIPOLYGON (((24.12418 48.53414, 24.12468 48... |
| 1 | верховинський район | MULTIPOLYGON (((24.55440 48.11795, 24.55552 48... |
| 2 | галицький район | MULTIPOLYGON (((24.50252 49.22315, 24.50174 49... |
| 3 | городенківський район | MULTIPOLYGON (((25.20973 48.71072, 25.20952 48... |
| 4 | долинський район | MULTIPOLYGON (((23.54708 48.72539, 23.54779 48... |
gdf.geometry[15] = gdf.geometry[12][1]
gdf_regions = gdf[gdf['name'].str.endswith('район')].copy()
gdf_regions.geometry = gdf_regions.geometry.apply(lambda p: p[0].boundary)
gdf_cities = gdf[~gdf['name'].str.endswith('район')].copy()
gdf_cities.geometry = gdf_cities.geometry.apply(lambda p: p.boundary)
gdf.geometry = gdf.geometry.apply(lambda p: p.boundary)
regions = alt.Chart(gdf_regions).mark_geoshape(stroke = 'white', strokeWidth = 2).encode(
color = 'name:N'
)
cities = alt.Chart(gdf_cities.head(5)).mark_geoshape(stroke = 'black', strokeWidth = 3).encode(
color = 'name:N'
)
(regions + cities).properties(width = 550, height = 720, background = '#F9F9F9')
df_test = df.copy()
df_test['raion_name'] = df_test['raion_name'].str.lower()
empty = alt.Chart(alt.Data(values = [])).mark_circle().configure(background = '#f9f9f9',
padding = 25).configure_view(strokeWidth = 0,
width = 800,
height = 600).configure_title(fontSize=26,
font='Courier',
anchor='middle',
color='gray',
subtitleFontSize=18,
subtitleFont='Courier').configure_text(font = 'Courier').configure_legend(
orient='bottom',
direction='vertical',
labelLimit=1500,
titleFontSize=16,
titleFont='Courier',
labelFontSize=20,
titleColor='grey',
labelColor='grey',
symbolSize=100).configure_axis(labelFont='Courier',
labelFontSize=15, titleFont='Courier', titleFontSize=20).configure_header(
titleFont='Courier',
titleFontSize=20,
labelColor='black',
# labelFont='Arial',
labelFontSize=16
)
empty.to_dict()
{'config': {'view': {'continuousWidth': 400,
'continuousHeight': 300,
'height': 600,
'strokeWidth': 0,
'width': 800},
'axis': {'labelFont': 'Courier',
'labelFontSize': 15,
'titleFont': 'Courier',
'titleFontSize': 20},
'background': '#f9f9f9',
'header': {'labelColor': 'black',
'labelFontSize': 16,
'titleFont': 'Courier',
'titleFontSize': 20},
'legend': {'direction': 'vertical',
'labelColor': 'grey',
'labelFontSize': 20,
'labelLimit': 1500,
'orient': 'bottom',
'symbolSize': 100,
'titleColor': 'grey',
'titleFont': 'Courier',
'titleFontSize': 16},
'padding': 25,
'text': {'font': 'Courier'},
'title': {'anchor': 'middle',
'color': 'gray',
'font': 'Courier',
'fontSize': 26,
'subtitleFont': 'Courier',
'subtitleFontSize': 18}},
'data': {'values': []},
'mark': 'circle',
'$schema': 'https://vega.github.io/schema/vega-lite/v4.17.0.json'}
def cool_theme():
# return empty.to_dict()
return {'config': {'view': {'continuousWidth': 400,
'continuousHeight': 300,
'height': 600,
'strokeWidth': 0,
'width': 800},
'axis': {'domain': False,
'gridDash': [2, 2],
'labelFont': 'Courier',
'labelFontSize': 15,
'labelPadding': 10,
'ticks': False,
'titleFont': 'Courier',
'titleFontSize': 20},
'background': '#f9f9f9',
'legend': {'labelFont': 'Ubuntu Mono',
'labelFontSize': 20,
'titleFont': 'Courier',
'titleFontSize': 16,
'direction': 'vertical',
'labelColor': 'grey',
'labelLimit': 1500,
'orient': 'bottom',
'symbolSize': 100,
'titleColor': 'grey'},
'padding': 20,
'text': {'font': 'Courier'},
'title': {'anchor': 'middle',
'color': 'gray',
'font': 'Courier',
'fontSize': 26,
'subtitleFont': 'Courier',
'subtitleFontSize': 26},
'header': {'labelColor': 'black',
'labelFontSize': 16,
'titleFont': 'Courier',
'titleFontSize': 20}},
'data': {'values': []}}
alt.themes.register('cool_theme', cool_theme)
alt.themes.enable('cool_theme')
ThemeRegistry.enable('cool_theme')
def price_categorization(row):
if row['price'] < 20:
val = 'Крихітна'
elif row['price'] < 25:
val = 'Мала'
elif row['price'] < 75:
val = 'Стандартна'
elif row['price'] <= 100:
val = 'Помірна'
else:
val = 'Висока'
return val
df_test['price_category'] = df_test.apply(price_categorization, axis=1)
df_test['raion_name'] = df_test['raion_name'].str.capitalize()
df_test.head()
| code | status | name | rada_name | raion_name | oblast_name | year | date | number | square | population | documentation_price | price | population_coeff | near_110k_city_coeff | resort_coeff | radiation_coeff | city_building_min_coeff | city_building_max_coeff | price_category | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2620480401 | СЕЛО | БАБЧЕ | БАБЧЕНСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 10!05!2011 | № 8-5/11 | 895,3 | 2501 | 8.200 | 17.32 | 1.0 | 1.0 | 1.0 | 1.0 | 0.80 | 1.18 | Крихітна |
| 1 | 2620455100 | СЕЛИЩЕ МІСЬКОГО ТИПУ | БОГОРОДЧАНИ | БОГОРОДЧАНСЬКА СЕЛИЩНА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 18!10!2011 | № 401 | 687,6 | 7686 | 30.000 | 45.63 | 1.0 | 1.1 | 1.0 | 1.0 | 0.79 | 1.28 | Стандартна |
| 2 | 2620480601 | СЕЛО | БОГРІВКА | БОГРІВСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2012.0 | 29!12!2012 | №1-22/2012 | 396,3 | 869 | 5.352 | 22.92 | 1.0 | NaN | NaN | NaN | 0.86 | 1.22 | Мала |
| 3 | 2620480901 | СЕЛО | ГЛИБІВКА | ГЛИБІВСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 15!09!2011 | № 77-8/2011 | 427,27 | 1047 | 4.600 | 24.20 | 1.0 | 1.1 | 1.0 | 1.0 | 0.75 | 1.14 | Мала |
| 4 | 2620480801 | СЕЛО | ГЛИБОКА | ГЛИБОКІВСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2013.0 | 10!07!2013 | №383-19/2013 | 480,3 | 1452 | 7.636 | 21.84 | NaN | NaN | NaN | NaN | 0.81 | 1.15 | Мала |
gdf_regions.columns = ['raion_name', 'geometry']
gdf_regions['raion_name'] = gdf_regions['raion_name'].str.capitalize()
gdf_regions.head()
| raion_name | geometry | |
|---|---|---|
| 0 | Богородчанський район | LINESTRING (24.12418 48.53414, 24.12468 48.535... |
| 1 | Верховинський район | LINESTRING (24.55440 48.11795, 24.55552 48.116... |
| 2 | Галицький район | LINESTRING (24.50252 49.22315, 24.50174 49.222... |
| 3 | Городенківський район | LINESTRING (25.20973 48.71072, 25.20952 48.710... |
| 4 | Долинський район | LINESTRING (23.54708 48.72539, 23.54779 48.725... |
gdf_regions["centroid_x"] = gdf_regions.geometry.apply(lambda p: p.centroid.x)
gdf_regions["centroid_y"] = gdf_regions.geometry.apply(lambda p: p.centroid.y)
place = "Івано-Франківська область"
lat, lon = get_lat_lon(place)
df = get_region_geodata(place)
df.geometry = df.geometry.apply(lambda p: p[0].boundary)
background = alt.Chart(df).mark_geoshape(stroke = 'gray', strokeWidth = 2).encode(
color = alt.value('gray'),
opacity = alt.value(0.3),
)
regions = alt.Chart(gdf_regions).transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = pd.DataFrame(df_test.groupby(['raion_name'])['price'].mean()).reset_index(level=['raion_name']),
key = 'raion_name',
fields=['price', 'raion_name'])
).mark_geoshape(stroke = 'gray', strokeWidth = 2).encode(
color = alt.Color('price:Q', legend=alt.Legend(orient='left', title='Середня Ціна, грн/м\u00b2', titleLimit=800)),
)
labels = alt.Chart(gdf_regions).transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = pd.DataFrame(df_test.groupby(['raion_name'])['price'].mean()).reset_index(level=['raion_name']),
key = 'raion_name',
fields=['price', 'raion_name'])
).mark_text(color='black', lineBreak=r'nnn').encode(
longitude='centroid_x:Q',
latitude='centroid_y:Q',
text='label:N',
size=alt.value(10),
color = alt.condition(
alt.datum.price > 30,
alt.value('white'),
alt.value('black')
)
).transform_calculate(
label = "substring(datum.raion_name,0,indexof(datum.raion_name, ' ')) + 'nnn' + substring(datum.price, 0, 4)"
)
source_img = pd.DataFrame.from_records([
{"img": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/Coat_of_Arms_of_Ivano-Frankivsk_Oblast.svg/1200px-Coat_of_Arms_of_Ivano-Frankivsk_Oblast.svg.png"}
])
emblem = alt.Chart(source_img).mark_image(
width=120,
height=120
).encode(
url='img'
)
Головним завданням цієї візуалізації було зобразити середні значення ціни землі за квадратний метр в районах Івано-Франківської області. Графік повинен допомогти побачити саме розподіл цін по районах для зручного порівняння наприклад між Галицьким і Тисменицьким районами. Для вирішення даного завдання я розглядав різні варіанти візуалізації, один з них буде зображений на наступній візуалізації. Цей графік допомагає показати також залежність цін від географічного положення району, наприклад чи земля в сусіддніх районах коштує приблизно однаково, чи дорожчі райони на півдні. Така візуалізація була обрана завдяки своїй зручності у навігації. Ми швидко можемо знайти потрібний район і одразу побачити вартість землі за квадратний метр, кольорова гама дозволяє оцінити розподіл цінової різниці між районами і швидко сконцентрувати увагу на найдорожчий (Тисменицкий) чи найдешевший (Тлумацький) райони, які до того ж є сусідами. Неперервна шкала легенди була обрана, щоб не забирати на себе багато уваги, а лише загалом дати уявлення про відповідність числових і кольорових характеристик. Акцентуватись на ній не потрібно, бо для кожного регіону ітак представлені конкретні значення ціни. Варто звернути увагу, що кольори підписів представлені двома варіантами, щоб отримти кращий контраст між кольором регіону і кольором підпису назви, в протилежному випадку наприклад білий і зеленувато-жовтий колір практично зливаються. Емблема Івано-Франківської області не несе інформації, яка відповідала б на якесь запитання, але в той же ж час додає візуалізації офіційності і елегантності, не заважаючи. Іншим варіантом графіка, було представлення інформації через тултіпи, що дає інтерактивності, але з іншого боку такий варіант, не дозволив би використати графік у друкованих джерелах, що є недоліком. І без тултіпа вся інформація донесена в повному об'ємі. Недоліком візуалізації є певні сірі регіони, для бекграунду я використав карту області, щоб не втратити жодну частину, але певні частини регіонів відсутні, бо вдалось отримати лише такі полігони. В майбутньому, приділивши більше часу, можна буде вручну підправити полігони для заповнення всієї карти області. Іншим незначним недоліком є виступи тексту назв за межі району, я пробував змінювати розміри і зсувати їх, але одного рішення, яке б закрило всі перетини, не знайшов.
(background + regions + labels + emblem).properties(width = 650, height = 720, title="Розподіл Середньої Ціни Землі за Районами").display(actions = False, renderer = 'png')
labels = df_test[df_test['raion_name'].str.endswith('район')].loc[:,['raion_name', 'price']].groupby('raion_name').mean().reset_index()
bars = alt.Chart(df_test[df_test['raion_name'].str.endswith('район')]).mark_bar().encode(
y=alt.Y('raion:N', sort=alt.SortField(field='mean_price', order='descending'), axis=alt.Axis(title='Район')),
x=alt.X('mean_price:Q', axis=alt.Axis(title='Середня ціна, грн/м\u00b2', values=[]))
).transform_aggregate(
mean_price='mean(price)',
groupby=["raion_name"]
).transform_calculate(
raion = "substring(datum.raion_name,0,indexof(datum.raion_name, ' '))"
)
text = bars.mark_text(dx = -25, size=20).encode(
text = alt.Text(field = "mean_price", type = 'quantitative', format = '.2s'),
color = alt.value('black')
)
На перший погляд цей графік відповідає на те саме запитання, що і попередній лише іншим способом, але розібравшись детальніше, можна зрозуміти різницю. Якщо попередня візуалізація базувалась на географічних характеристиках (ми фактично могли бачити розташування регіону на карті) і допомагало перевірити географічні залежності, то дана візуалізація уникає географічних абстракцій і призначена для зручнішого числового порівняння. Наприклад нам потрібно визначити умовно топ три найдорожчих райони, що дуже легко зробити по отриманому зображенню завдяки сортуванню по ціні. Крім того у нас виникає питання, який райони дуже схожі по ціні, на попередній візуалізації це було можливо зробити, але не дуже швидко і зручно, бо Снятинський і Рогатинський райони знаходяться далеко один від одного і кольорова характеристика не дуже рятує, а от в даній візуалізації, завдяки сортуванню, ми миттєво бачимо, які райони найближчі по ціні. І тут різниця в декілька копійок за метр не є важливою, коли наше завдання це порівняння рівнів цін. Ми з легкістю можемо сказати, що ціна в Богородчанському і Долинському районах практично ідентична. Недоліками цьієї візуалізації є переваги попередньої, тут у нас відсутня гографічна складова і сортування за алфавітом, тому знайти потрібний район займе зайву секунду.
(bars + text).properties(title="Розподіл Середньої Ціни Землі за Районами").display(actions = False, renderer = 'png')
top_all = list(pd.DataFrame(df_test[df_test['raion_name'].str.endswith('район')].groupby(['raion_name'])['price'].mean()).reset_index(level=['raion_name']).sort_values(by='price', ascending=False)['raion_name'])
top_5 = top_all[:5]
top_left = top_all[5:]
top_5
['Тисменицький район', 'Калуський район', 'Коломийський район', 'Богородчанський район', 'Долинський район']
raion_prices = pd.DataFrame(df_test[df_test['raion_name'].str.endswith('район')].groupby(['raion_name'])['price'].mean()).reset_index(level=['raion_name']).sort_values(by='price', ascending=False)
raion_prices.columns = ['raion_name', 'avg_price']
raion_prices.head()
| raion_name | avg_price | |
|---|---|---|
| 12 | Тисменицький район | 45.851569 |
| 5 | Калуський район | 33.824444 |
| 6 | Коломийський район | 32.622892 |
| 0 | Богородчанський район | 32.285854 |
| 4 | Долинський район | 32.030000 |
emoji_categories_top5 = alt.Chart(df_test[(df_test['raion_name'].str.endswith('район')) & (df_test['raion_name'].isin(top_5))]).mark_text(size=25, baseline='middle').transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = raion_prices,
key = 'raion_name',
fields=['avg_price', 'raion_name'])
).encode(
alt.X('x:O', axis=alt.Axis(title='Кількість міст', labelAngle=0)),
alt.Y('price_category:O', axis=alt.Axis(title=''), sort=['Висока', 'Помірна', 'Стандартна', 'Мала', 'Крихітна']), # axis = 0
alt.Row('raion:N', header=alt.Header(title='Цінові категорії по районах'), sort=alt.SortField(field='avg_price', order='descending')), #
alt.SizeValue(20),
alt.Text('emoji:N'),
).transform_calculate(
emoji="{'Крихітна': '💵', 'Мала': '💸', 'Стандартна': '💲', 'Помірна': '💰', 'Висока': '🤑'}[datum.price_category]",
raion = "substring(datum.raion_name,0,indexof(datum.raion_name, ' '))"
).transform_window(
x='rank()',
groupby=['raion_name', 'price_category']
)
top_left1 = alt.Chart(df_test[(df_test['raion_name'].str.endswith('район')) & (df_test['raion_name'].isin(top_left[:5]))]).mark_text(size=25, baseline='middle').transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = raion_prices,
key = 'raion_name',
fields=['avg_price', 'raion_name'])
).encode(
alt.X('x:O', axis=alt.Axis(title='Кількість міст', labelAngle=0)),
alt.Y('price_category:O', axis=alt.Axis(title=''), sort=['Висока', 'Помірна', 'Стандартна', 'Мала', 'Крихітна']), # axis = 0
alt.Row('raion:N', header=alt.Header(title='Цінові категорії по районах'), sort=alt.SortField(field='avg_price', order='descending')), #
alt.SizeValue(15),
alt.Text('emoji:N'),
).transform_calculate(
emoji="{'Крихітна': '💵', 'Мала': '💸', 'Стандартна': '💲', 'Помірна': '💰', 'Висока': '🤑'}[datum.price_category]",
raion = "substring(datum.raion_name,0,indexof(datum.raion_name, ' '))"
).transform_window(
x='rank()',
groupby=['raion_name', 'price_category']
)
top_left2 = alt.Chart(df_test[(df_test['raion_name'].str.endswith('район')) & (df_test['raion_name'].isin(top_left[5:]))]).mark_text(size=25, baseline='middle').transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = raion_prices,
key = 'raion_name',
fields=['avg_price', 'raion_name'])
).encode(
alt.X('x:O', axis=alt.Axis(title='Кількість міст', labelAngle=0)),
alt.Y('price_category:O', axis=alt.Axis(title=''), sort=['Висока', 'Помірна', 'Стандартна', 'Мала', 'Крихітна']), # axis = 0
alt.Row('raion:N', header=alt.Header(title='Цінові категорії по районах'), sort=alt.SortField(field='avg_price', order='descending')), #
alt.SizeValue(15),
alt.Text('emoji:N'),
).transform_calculate(
emoji="{'Крихітна': '💵', 'Мала': '💸', 'Стандартна': '💲', 'Помірна': '💰', 'Висока': '🤑'}[datum.price_category]",
raion = "substring(datum.raion_name,0,indexof(datum.raion_name, ' '))"
).transform_window(
x='rank()',
groupby=['raion_name', 'price_category']
)
Найцікавіший графік мого проекту. Головною задачею було донесення інформації про розподіл кількості міст кожної цінової категорії в різних районах. Середня ціна це цікава інформація, але нам потрібні деталі. Моєю підозрою було те, що в Тисменицькому районі середня ціна найбільша, бо в ньому знаходиться обласний центр, ціна якого дуже велика, а всі інші мітса мають дешеву ціну, але середнє значення виходить велике, я помилявся і в цьому мені допомогла переконатись дана візуалізація. По ній ми можемо зрозуміти, що наприклад в Тисменицькому районі є 4 міста, які належать до категорії з високою ціною, а переважна більшість, тобто 44 міста мають стандартну середню ціну за квадратний метр. Дане цінове групування є умовним (я шукав варіанти в Інтернеті, всі відрізняються, тому згрупував на свій розсуд), поділяємо 5 категорій (висока, помірна, стандартна, мала, крихітна ціни). Спершу розглядав варіант представити всі райони на одній картинці, але так максимально незручно навігуватись по них і неможливо побачити все одночасно, тому я прийняв рішення розділити представлення на дві візуалізації: в першій топ 5 найдорожчих районів (ними можуть частіше цікавитись), всі інші райони в двохколонній візуалізації. Райони відсортовані по середній ціні так само як і категорії, кожній з яких відповідає певний смайлик, що привертає і кконцентрує увагу на важливій інформації. Ми можемо прилизно прикинути розподіл міст за допомогою смайликів або ж побачити конкретну кількість за допомогою позначок на осі X. Недоліками візуалізаціє є розбиття на дві частини, на жаль, іншого варіанту не знайшов, різна кількість районів (4 і 5) на колонках другої візуалізації.
emoji_categories_top5.configure_view(strokeWidth = 1).properties(width=1000, height=160, title="Розподіл Кількості Міст по Цінових Категоріях в 5 Найдорожчих Районах").display(actions = False, renderer = 'png')
alt.hconcat(top_left1.properties(width=1000, height=160), top_left2.properties(width=1000, height=160)).configure_view(strokeWidth = 1).properties(title="Розподіл Кількості Міст по Цінових Категоріях у Інших Районах").display(actions = False, renderer = 'png')
df_test['status'].value_counts()
СЕЛО 746 СЕЛИЩЕ МІСЬКОГО ТИПУ 23 СЕЛИЩЕ 20 МІСТО 15 Name: status, dtype: int64
city_names = df_test[df_test['status'].isin(['СЕЛИЩЕ МІСЬКОГО ТИПУ', 'СЕЛИЩЕ', 'МІСТО'])][['name', 'price']]
city_names['lat'] = city_names.name.apply(lambda p: get_lat_lon(p + " івано-франківська область")[0])
city_names['lon'] = city_names.name.apply(lambda p: get_lat_lon(p + " івано-франківська область")[1])
city_names['name'] = city_names['name'].str.lower().str.capitalize()
city_names.head()
| name | price | lat | lon | |
|---|---|---|---|---|
| 1 | Богородчани | 45.63 | 48.807282 | 24.536780 |
| 19 | Бойки | 24.38 | 49.308701 | 24.782070 |
| 34 | Солотвин | 30.52 | 48.705493 | 24.423565 |
| 48 | Верховина | 46.92 | 48.153810 | 24.827930 |
| 76 | Стовпні | 15.32 | 48.002545 | 24.825778 |
df_test['name'] = df_test['name'].str.lower().str.capitalize()
regions_background = alt.Chart(gdf_regions).transform_lookup(
lookup = 'raion_name',
from_ = alt.LookupData(data = pd.DataFrame(df_test.groupby(['raion_name'])['price'].mean()).reset_index(level=['raion_name']),
key = 'raion_name',
fields=['price', 'raion_name'])
).mark_geoshape(stroke = 'white', strokeWidth = 2).encode(
color = alt.value('gray'),
opacity = alt.value(0.3),
)
points = alt.Chart(city_names).transform_lookup(
lookup = 'name',
from_ = alt.LookupData(data = df_test,
key = 'name',
fields=['price', 'name', 'raion_name', 'year'])
).mark_circle().encode(
longitude='lon:Q',
latitude='lat:Q',
size=alt.Size('price:Q', legend=alt.Legend(orient='left', title='Середня Ціна, грн/м\u00b2', titleLimit=800)),
color=alt.value('steelblue'),
tooltip=[alt.Tooltip('name:N', title="Місто"), alt.Tooltip('price:Q', title="Ціна, грн/м\u00b2"), alt.Tooltip('raion_name:N', title="Підпорядкування"), alt.Tooltip('year:N', title="Рік оцінки")]
)
У перших двох візуалізаціях ми розглядали суто райони, а в попередніх ми вже почали вивчати інформацію по містах. В цій візуалізації ми в першу чергу хочемо дослідити розподіл цін по містах області. Для цього знову використаємо географічне представлення даних і бульбашки, що представляють міста. У цьому графіку я вж не зміг обійтись без інтерактивності, а саме без використання тултіпів. Отож, при наведенні курсора на бульбашку, що репрезентує певне місто, ми отримуємо інформацію про назву міста, ціну за землю, підпорядкування землі до району чи міста обласного значення та про рік проведення останньої оцінки. Щоб представлення не було занадто засміченим, я візуалізую лише міста та селища міського типу, уникаючи всі села. Розмір бульбашки (міста) залежить від ціни землі за нього. Дана візуалізація допомагає побачити, що ціна за землю в Калуші набагато більша за ціну в Івано-Франківську, що дивно, проте за допомогою тих же ж тултіпів, ми бачимо, що оцінка в обласному центрі проводилась аж в 2014 році (в наступній візуалізації ми побачимо, що така інформація вже не актуальна), а в Калішу оцінка була в 2021, це і пояснює таку дороговизну. Крім такого представлення я експериментував з накладанням текстових позначок міст і значень, схожим способом, як і в першій візуалізації, але тут її потрібно більше, а це нагромаджує дані, тому найкращим способом я все ж обрав інтерактивність, для отримання деталей про те місто, яке цікавить. В майбітньому до випадаючого вікна можна також дадати герби міст. Недоліками даного представлення даних є неможливість повного сприйняття, якщо ми наприклад захочимо надрукувати його на папері, не можемо знайти місто по назві, а лие по локація або ж переглядати всі підряд, деякі міста дуже малі, трішки незручно наводити курсор на них, важко порівняти два конкретні міста. Проте, незважаючи на вказані недоліки, візуалізація добре справляється з поставленим завданням.
(background + regions_background + points).project(
type='mercator', scale=12000, center=[lon, lat]
).properties(title="Розподіл Цін на Землю по Містах Області")
def actuality(year):
if year <= 2010:
return 0
elif year <= 2016:
return 1
else:
return 2
df_years = pd.DataFrame(df_test.groupby(['year'])['year'].count())
df_years.columns = ['count']
df_years = df_years.reset_index(level=['year'])
df_years['year'] = pd.to_numeric(df_years['year'], downcast="integer")
df_years['actuality'] = df_years.year.apply(lambda p: actuality(p))
df_years.head()
| year | count | actuality | |
|---|---|---|---|
| 0 | 2000 | 4 | 0 |
| 1 | 2001 | 1 | 0 |
| 2 | 2002 | 3 | 0 |
| 3 | 2003 | 2 | 0 |
| 4 | 2004 | 1 | 0 |
line_1 = alt.Chart(pd.DataFrame({'x': [2010.5, 2010.5], 'y': [0.0, 180.0]})).mark_line(color="#a9a9a9").encode(
x = alt.X('x:N'),
y = 'y:Q',
)
line_2 = alt.Chart(pd.DataFrame({'x': [2016.5, 2016.5], 'y': [0.0, 180.0]})).mark_line(color="#a9a9a9").encode(
x = alt.X('x:N'),
y = 'y:Q',
)
text1 = alt.Chart({'values':[{'x': 2005, 'y': 150}]}).mark_text(
text='Застаріла', font='Courier', fontSize=26, align = 'center').encode(
x='x:N',
y='y:Q',
opacity=alt.value(0.5)
)
text2 = alt.Chart({'values':[{'x': 2015, 'y': 150}]}).mark_text(
text='Сумнівна', font='Courier', fontSize=26, align = 'right').encode(
x='x:N',
y='y:Q',
opacity=alt.value(0.5)
)
text3 = alt.Chart({'values':[{'x': 2019, 'y': 150}]}).mark_text(
text='Актуальна', font='Courier', fontSize=26, align = 'center').encode(
x='x:N',
y='y:Q',
opacity=alt.value(0.5)
)
actuality = alt.Chart(df_years).mark_bar().encode(
x=alt.X('year:O', axis = alt.Axis(title='Рік', labelAngle=0, values=[i for i in range(2000, 2022)])),
y=alt.Y("count:Q", axis=alt.Axis(title='Кількість')),
color= alt.Color('actuality:N', scale=alt.Scale(scheme='redyellowgreen'), legend=None) # , legend=alt.Legend( title='Актуальність')
)
Як ми побачили на прикладі різниці цін і років оцінки Калуша і Івано-Франківська, інформація про останній рік проведення оцінки є важливою. Ця візуалізація покликана допомогти зрозуміти розподіл кількості міст по роках, зрозуміти чи можемо ми довіряти останнім офіційним оцінкам, чи є інформація актуальною. Графік представлений у вигляді гістограми по роках, візуально інформація розділена двома прийомами: кольором і лініями, щоб розділити оцінку по актуальності. Лінія представляє базове розмежування, а от колір ще й відображає градацію актуальності (червоний-стара, зелена-актуальна). Графік дає нам відповідь на питання, чи ми можемо довіряти такій інформації (В основному, на жаль, ні). Більшість проведених оцінок міст належать до категорії Сумнівна актуальність, якщо ще такі дані ми більш-менш можемо враховувати, то ціни старіші за 2011 рік є зовсім не актуальними і таких населених пунктів є також вагома кількість. Нещодавно (2017-2021 роки) оцінка проводилась в невеликій кількості населених пунктів, інформації про них ми можемо повністю довіряти, але таких міст - одиниці. У першій версії актуальність позначалась легендою, що було не зовсім зрозуміло, в фінальній версії, додаткова підказка знаходиться під заголовком, а сама легенда (пояснення) інкорпорована в графік. Багато недоліків було виправлено після фідбеку, тому зараз для мене помітний один - лінії розмежування візуально займають один рік по осі X, що може трішки заплутати, але цей недолік незначний.
(actuality + line_1 + line_2 + text1 + text2 + text3).properties(height=500, width = 1200, title={
"text": ["Розподіл Кількості Неселених Пунктів за Роками Проведення Оцінки"],
"subtitle": ["Інформація розділена за актуальністю оцінки"],
}).display(actions = False, renderer = 'png')
df_test.head(3)
| code | status | name | rada_name | raion_name | oblast_name | year | date | number | square | population | documentation_price | price | population_coeff | near_110k_city_coeff | resort_coeff | radiation_coeff | city_building_min_coeff | city_building_max_coeff | price_category | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2620480401 | СЕЛО | Бабче | БАБЧЕНСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 10!05!2011 | № 8-5/11 | 895,3 | 2501 | 8.200 | 17.32 | 1.0 | 1.0 | 1.0 | 1.0 | 0.80 | 1.18 | Крихітна |
| 1 | 2620455100 | СЕЛИЩЕ МІСЬКОГО ТИПУ | Богородчани | БОГОРОДЧАНСЬКА СЕЛИЩНА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2011.0 | 18!10!2011 | № 401 | 687,6 | 7686 | 30.000 | 45.63 | 1.0 | 1.1 | 1.0 | 1.0 | 0.79 | 1.28 | Стандартна |
| 2 | 2620480601 | СЕЛО | Богрівка | БОГРІВСЬКА СІЛЬСЬКА РАДА | Богородчанський район | ІВАНО-ФРАНКІВСЬКА | 2012.0 | 29!12!2012 | №1-22/2012 | 396,3 | 869 | 5.352 | 22.92 | 1.0 | NaN | NaN | NaN | 0.86 | 1.22 | Мала |
df_test = df_test[df_test['square'].notna()]
df_test['square'] = df_test['square'].str.replace(",", ".").astype(float)
C:\Users\trume\AppData\Local\Temp/ipykernel_20996/3421743038.py:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
df_test['square'] = df_test['square'].str.replace(",", ".").astype(float)
max_square = df_test['square'].max()
max_price = int(df_test['price'].max())
line = alt.Chart(df_test).mark_line(
color='red',
size=3
).transform_window(
rolling_mean='mean(price)'
).encode(
x='square:Q',
y='rolling_mean:Q'
)
points = alt.Chart(df_test).mark_point().encode(
x=alt.X('square:Q', axis = alt.Axis(title='Площа, га')),
y=alt.Y('price:Q', axis=alt.Axis(title="Ціна, грн/м\u00b2"))
)
line_label = line.mark_text(align = 'left', dx = 5, size=13, color='red', lineBreak=r'nnn').transform_filter(
alt.datum.square == max_square
).encode(
text = alt.value("Рухоме Cереднє nnn Значення Ціни")
)
area = pd.DataFrame.from_dict({'x': [3000], 'x1': [4500], 'y': [0], 'y1': [240]})
shading = alt.Chart(area).mark_rect(opacity = 0.1, fill = 'gray').encode(
x = alt.X('x:Q'),
y = alt.Y('y:Q'),
x2 = alt.X2('x1:Q'),
y2 = alt.Y2('y1:Q')
)
shading_label = alt.Chart(
pd.DataFrame.from_dict({'x': [3800], 'y': [200], 'text': ['Великі міста']})
).mark_text(dy = 10, size=20).encode(
x = alt.X('x:Q'),
y = alt.Y('y:Q'),
text = alt.Text('text:N')
)
Завершальна візуалізація проекту, на основі, якої можна зробити ще низку схожих представлень. Тут ми намагаємось дослідити залежність ціни в населеному пункті від його площі.Населені пункти представлені кружечками у відповідному місті графіка, а червона лінія це рухоме середнє значення, яке узагальнює інформацію і конуентрує на собі увагу, щоб ми не розпорошувались і не відволікались на окремі кружечки (міста). За допомогою візуалізації рухомого середнього значення, ми розуміємо, що впринципі сама площа не впливає на ціну, що є цікавим спостереженням. На графіку невеликою тінню (сіріший колір) з текстовою підказкою позначена зона великих за площею міст, яких не так вже й багато. В інших способах, які я розглядав, я намагався змінити розмірність (scale) по осі X, щоб не скупчувати багато малих міст разом, але так графік виходить дуже розтягненим, а кожне окреме місто нас не цікавить, тому я пожертував цим на вигоду загальній картинці. Також я пробував візуалізувати не рухоме середнє значення, а різні поліноми, але вирішим, що фінальна версія найбільш репрезентативна. Недоліком такого представлення є скупчення багатьох малих міст і аналогічно дуже вузькі коливання рухомого середнього біля скупчення міст, але я свідомо цим пожертвував для отримання не широкого, зрозумілого загалом графіка. Ця візуалізація не потребує інтерактивності, тому можна як і більшість попередніх зарендерити без кнопочки і у форматі .png.
(points + line + line_label + shading + shading_label).properties(width = 1300, title="Залежність Ціни Землі від Площі Населеного Пункту").display(actions = False, renderer = 'png')